iT邦幫忙

2026 iThome 鐵人賽

0
Software Development

Kotlin Lambda 從零開始系列 第 33

Kotlin Lambda 從零開始 Day 33:自訂運算子與中綴函式 — 讓 Collection 操作更優雅

  • 分享至 

  • xImage
  •  

https://ithelp.ithome.com.tw/upload/images/20260807/201219480fj7uFk1cv.jpg

這篇文章會用 TDD 實作自訂 operator funinfix fun,把之前零散出現的運算子觀念(day 09 的 contains、day 12 的 to、day 26 的 plus/minus)做個完整的整理

這兩組東西都不小,Kotlin 約定好的 operator 名稱是一整組,infix 函式在 stdlib 裡也散落各處,這篇不會全部都實作一遍,實際動手的是 getinvokepluscontains 四個 operator 加上兩個自訂的 infix

Kotlin ↔ C# 對照表

Kotlin C# 備註
operator fun plus public static T operator + Kotlin 是成員/擴充函式,C# 是靜態方法
operator fun get this[int index] (indexer) Kotlin 用 get/set,C# 用 indexer
operator fun invoke 無直接對應 C# 沒有讓物件當函式呼叫的語法
operator fun contains 無直接對應 C# 沒有 in 運算子
infix fun 無直接對應 C# 沒有中綴語法

C# 的 operator overloading 用靜態方法定義。Kotlin 用 operator 修飾詞標記在成員函式或擴充函式上,寫法比 C# 靈活

operator fun 對照表

Kotlin 的 operator 是一組約定好的函式名稱。compiler 看到特定語法就會去找對應的 operator fun

語法 對應函式 範例
+a a.unaryPlus() 一元正號
-a a.unaryMinus() 一元負號
!a a.not() 一元否定
a + b a.plus(b) day 26 的 myPlus
a - b a.minus(b) day 26 的 myMinus
a * b a.times(b)
a / b a.div(b)
a % b a.rem(b)
a[i] a.get(i) 今天實作
a[i] = v a.set(i, v)
a in b b.contains(a) day 09 的 myContains
a() a.invoke() 今天實作
a > b a.compareTo(b) > 0 day 15 的 Comparable
a..b a.rangeTo(b)
a..<b a.rangeUntil(b) 不含結尾

前三列是一元運算子,作用在單一操作數上。a..ba..<b 是範圍運算子,差別在後者不含結尾的 b

不是所有函式都需要 operator。只有 Kotlin 規定好的那組名稱才對應得到語法糖,上表列的是最常用的幾個,另外還有 inc / dec(++ / --)、plusAssign 這組 += 系列、equals(==)、componentN(解構,day 13 看過)、iterator(for-in),以及委派屬性用的 getValue / setValue / provideDelegate

完整清單可以看官方的 operator overloading 文件

TDD 實作 get 與 invoke 運算子

前面幾篇都是在既有的 List 上刻擴充函式,這篇不一樣。要示範的是「一個型別怎麼讓自己支援 []()+in 這些語法」,四個 operator 掛在同一個 class 上才看得出全貌,所以先自己準備一個容器:MyIntList 內部就包一個 List<Int>,其他什麼都還沒有,接下來的測試都對它寫

Red:先寫測試

@Test
fun `get operator with bracket syntax`() {
    val list = MyIntList(listOf(10, 20, 30))
    assertEquals(10, list[0])
    assertEquals(30, list[2])
}

@Test
fun `invoke operator as function call`() {
    val list = MyIntList(listOf(1, 2, 3, 4, 5))
    val result = list { it > 3 }
    assertEquals(listOf(4, 5), result)
}

@Test
fun `plus operator combines lists`() {
    val a = MyIntList(listOf(1, 2))
    val b = MyIntList(listOf(3, 4))
    val result = a + b
    assertEquals(listOf(1, 2, 3, 4), result.toList())
}

@Test
fun `contains operator with in keyword`() {
    val list = MyIntList(listOf(1, 2, 3))
    assertTrue(2 in list)
    assertFalse(5 in list)
}

@Test
fun `get throws when index out of bounds`() {
    val list = MyIntList(listOf(10, 20))
    assertThrows<IndexOutOfBoundsException> { list[5] }
}

@Test
fun `invoke on empty list returns empty`() {
    val list = MyIntList(emptyList())
    assertEquals(emptyList<Int>(), list { it > 0 })
}

@Test
fun `plus with empty list keeps original elements`() {
    val a = MyIntList(listOf(1, 2))
    val empty = MyIntList(emptyList())
    assertEquals(listOf(1, 2), (a + empty).toList())
}

@Test
fun `contains on empty list is always false`() {
    val list = MyIntList(emptyList())
    assertFalse(1 in list)
}

list[0] 對應 getlist { ... } 對應 invokea + b 對應 plus2 in list 對應 contains。四種語法,四個 operator

邊界案例也一起測:索引越界要丟 IndexOutOfBoundsException,空集合的 invokepluscontains 行為要正確。get 的越界例外其實是底層 List 直接丟出來的,這點等實作完再看

Green:最小實作

class MyIntList(private val elements: List<Int>) {
    operator fun get(index: Int): Int = elements[index]

    operator fun invoke(filter: (Int) -> Boolean): List<Int> =
        elements.filter(filter)

    operator fun plus(other: MyIntList): MyIntList =
        MyIntList(elements + other.elements)

    operator fun contains(element: Int): Boolean =
        elements.contains(element)

    fun toList(): List<Int> = elements
}

每個 operator 都只要一行。get[] 語法可用,invoke 讓物件可以當函式呼叫,plus+ 可用,containsin 可用

invoke 比較特別。它讓 MyIntList 的實例可以直接加小括號呼叫,像函式一樣。list { it > 3 } 等於 list.invoke { it > 3 }

越界測試會直接通過:get 內部呼叫 elements[index],索引超出範圍時是底層的 List 丟出 IndexOutOfBoundsException,我們不用自己檢查

Refactor:往 stdlib 的寫法靠近

stdlib 的集合不會把所有 operator 散在 class 裡,而是讓型別實作 Iterable,再把通用操作做成擴充函式。我們可以往這個方向靠:讓 MyIntList 實作 Iterable<Int>containsplus 這類就能改用 stdlib 既有的擴充函式,不用每個都自己寫

class MyIntList(private val elements: List<Int>) : Iterable<Int> {
    operator fun get(index: Int): Int = elements[index]

    operator fun invoke(filter: (Int) -> Boolean): List<Int> =
        elements.filter(filter)

    operator fun plus(other: MyIntList): MyIntList =
        MyIntList(elements + other.elements)

    override fun iterator(): Iterator<Int> = elements.iterator()

    fun toList(): List<Int> = elements
}

實作 Iterable<Int> 之後,2 in list 會自動對應到 stdlib 的 Iterable<T>.contains 擴充函式,原本手寫的 contains 就可以刪掉。plus 也能換成 stdlib 的 Iterable<T>.plus,這裡保留自訂版本是為了回傳 MyIntList 而非 List。測試完全不用改,照樣綠燈,這正是 stdlib 用 Iterable 當共同抽象的好處

infix fun 深入

infix 讓二元函式可以省略 .()

// 一般呼叫
1.to("one")

// 中綴呼叫
1 to "one"

day 12 的 associate 用到了 to,day 26 的 unionintersectsubtract 也都是 infix

infix 的限制

三個限制

  1. 只能有一個參數(不能 vararg)
  2. 必須是成員函式或擴充函式(不能是沒有 receiver 的普通頂層函式)
  3. 不能有預設值(官方文件如此規定,不過目前編譯器沒有實際擋,寫了也編得過)
// 合法
infix fun String.myRepeat(n: Int): String { ... }

// 不合法:兩個參數
// infix fun String.between(start: Int, end: Int): String  ← 編譯錯誤

TDD 實作自訂 infix

Red:先寫測試

@Test
fun `myTo creates pair`() {
    val pair = "name" myTo "Alice"
    assertEquals(Pair("name", "Alice"), pair)
}

@Test
fun `myRepeat repeats string`() {
    val result = "ab" myRepeat 3
    assertEquals("ababab", result)
}

@Test
fun `myRepeat with zero returns empty string`() {
    val result = "ab" myRepeat 0
    assertEquals("", result)
}

myRepeat 0 是邊界案例,重複零次應該回傳空字串。myTo 不會失敗,任何兩個值都能配對,所以不另外測例外

Green:最小實作

infix fun <A, B> A.myTo(that: B): Pair<A, B> = Pair(this, that)

infix fun String.myRepeat(n: Int): String {
    val sb = StringBuilder()
    repeat(n) { sb.append(this) }
    return sb.toString()
}

myTo 跟 stdlib 的 to 幾乎一樣。用泛型讓任何型別都可以配對

myRepeatinfix 讓語法讀起來像英文:"ab" myRepeat 3。比 "ab".myRepeat(3) 更直覺。repeat(0) 不會跑迴圈,sb 維持空字串,邊界測試直接通過

Refactor:往 stdlib 的寫法靠近

stdlib 早就有 String.repeat(n),自己疊 StringBuilder 是多餘的,可以直接委派過去

infix fun String.myRepeat(n: Int): String = this.repeat(n)

stdlib 的 repeatn 為負數時會丟 IllegalArgumentException,我們的版本委派之後也跟著有了這個行為,不用自己處理

myTo 已經跟 stdlib 的 to 一致,沒有東西可以再簡化

什麼時候適合用 infix?

適合用 infix 的場景:函式名稱讀起來像介系詞或動詞,兩個操作數之間有明確的關係

// 好的 infix 用法
1 to "one"           // A 配對 B
listA union listB    // A 聯集 B
route shouldBe 200   // 測試框架常見

// 不適合的 infix 用法
// string process config  ← 語意不清

operator 和 infix 的差異

operator infix
語法 符號(+[]in) 函式名稱(tounion)
函式名稱 固定的(plus、get、contains) 自訂
參數數量 由運算子決定 一定是一個
修飾詞 operator infix

operator 是把函式對應到符號語法,名稱是固定的。infix 是把函式呼叫變成中綴語法,名稱自己取

底層原理:編譯後就是普通函式呼叫

operatorinfix 都是純粹的編譯期語法糖。compiler 在解析階段把符號或中綴語法翻譯成一般的函式呼叫,JVM 指令層面看到的就是普通的方法呼叫(修飾詞本身還是會記在 Kotlin 的 @Metadata annotation 裡,不然別的 module 不會知道這個函式能用中綴語法叫)

a + b 為例,compiler 解糖後等於 a.plus(b)2 in list 等於 list.contains(2)"ab" myRepeat 3 等於 "ab".myRepeat(3)。把這幾行用 IntelliJ 的「Show Kotlin Bytecode → Decompile」反編譯回 Java,看到的就是普通的方法呼叫

// "ab" myRepeat 3 反編譯後(示意)
MyInfixKt.myRepeat("ab", 3);

這也解釋了兩件事,第一,operatorinfix 沒有任何 runtime 成本,跟手寫函式呼叫一樣快

第二,因為底層是同一個函式,你可以同時用兩種寫法呼叫:1.to("one")1 to "one" 跑的是完全相同的程式碼

operator 的濫用風險

operator 給開發者極大的自由,但用過頭就是災難

Kotlin 社群對自訂 operator 一直保持警覺

  • 語意不直覺a + b 對 List 是合併、對 Set 是聯集、對自訂 class 可能做任何事,讀者要先看實作才知道意思
  • 降低可搜尋性:程式碼裡的 + 跟內建加法看起來一樣,grep 不到自訂 plus 的呼叫點
  • error message 變難解:operator 解析失敗時 compiler 的錯誤訊息很抽象,新手難以理解

C# 的 operator overloading 也有同樣爭議,.NET Framework Design Guidelines 明確建議:只有「跟基本型別行為類似」的場景才用 operator overloading,如複數、矩陣、向量。對 domain object 一律用具名方法

Kotlin stdlib 自己也很節制,Collection 有 +-in,Map 有 [],但這些都是業界慣例(數學集合運算或索引存取)

實作建議:operator 只用在數學概念(向量、複數)、不可變值物件、集合運算;業務 entity 永遠用具名方法;infixoperator 安全一點,因為函式名還在

與 stdlib 原始碼比較

原始碼位置:kotlinTuples.ktkotlin.textStringsJVM.kt

stdlib 的 to 函式

public infix fun <A, B> A.to(that: B): Pair<A, B> = Pair(this, that)

跟我們的 myTo 一模一樣。stdlib 的 unionintersectsubtract 也都是 infix,day 26 看過了

repeat 就不一樣了

public actual fun CharSequence.repeat(n: Int): String {
    require(n >= 0) { "Count 'n' must be non-negative, but was $n." }

    return when (n) {
        0 -> ""
        1 -> this.toString()
        else -> {
            when (length) {
                0 -> ""
                1 -> this[0].let { char -> String(CharArray(n) { char }) }
                else -> {
                    val sb = StringBuilder(n * length)
                    for (i in 1..n) {
                        sb.append(this)
                    }
                    sb.toString()
                }
            }
        }
    }
}

最大的差別是它沒有 infix。我們的 myRepeat 加上去只是為了示範中綴語法,但 "ab" repeat 3 其實不好讀,repeat 是動詞、後面接的是次數,兩個操作數之間沒有介系詞式的關係,剛好是前面那節「什麼時候適合用 infix」的反例,stdlib 選擇不加,可能是對的

另外兩個細節,receiver 是 CharSequence 不是 String,所以 StringBuilder 這類也適用,開頭的 require(n >= 0) 就是我們委派過去之後拿到的那個 IllegalArgumentException,後面兩層 when 全是捷徑,重複 0 次或字串本身是空的直接回傳 "",重複 1 次直接 toString(),單一字元用 CharArray 填滿,全都避開了 StringBuilder,只有真正需要疊字串的情況才走到最後一支,而且連容量都先算好 n * length

順帶一提,這個函式是 expect/actual:宣告在 common 的 TextH.kt,上面這份是 JVM 的實作,JS、Native、Wasm 各有自己的版本

小結

operator 把函式綁定到符號語法上,名稱是 Kotlin 規定好的

invoke 讓物件可以當函式用,get 讓物件支援 []infix 是另一回事,它讓自訂名稱的函式可以用中綴語法呼叫,讀起來更像自然語言,兩者都是語法糖,底層就是普通的函式呼叫

下一篇進入泛型進階,看 out/in 型變和 reified 怎麼讓泛型更強大

參考資料


Yes


同步刊登於 Blog

圖片來源:AI 產生


上一篇
Kotlin Lambda 從零開始 Day 32:Scope Functions 與 Collection 的組合技
下一篇
Kotlin Lambda 從零開始 Day 34:泛型進階 — 型變、星號投影與 reified
系列文
Kotlin Lambda 從零開始35
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言